iT邦幫忙

2022 iThome 鐵人賽

DAY 3
0
Modern Web

Rails,我要進來囉系列 第 3

第三天:為什麼 Rails 不需要常常使用 require?Rails 的 autoloading 是什麼魔法?

  • 分享至 

  • xImage
  •  

第三天:為什麼 Rails 不需要常常使用 require?Rails 的 autoloading 是什麼魔法?

開場白

鼬~~~哩賀,我是寫程式的山姆老弟,今天是我們的開賽第三天!

今天要來看的是,RailsGuides 的 **Autoloading and Reloading Constants 篇,究竟 Rails 在開發的過程當中,為什麼不太需要使用到 require,去引用其他 ruby 檔呢?

Untitled

如果沒有 autoloading,會是什麼樣子的?

Rails 以 MVC 為主軸,分為 Model-View-Controller,三者互相緊密合作,Model 負責從資料庫拿出資料、View 負責呈現資料、Controller 負責接收請求、將最終渲染後的網頁傳給客戶端,如果沒有 autoloading,Controller 在跟 Model 拿資料時,就會像下面這樣

# app/controllers/users_controller.rb
require '../models/user.rb' # 需要多這道程序,才能使用 User 這個 Class

class UsersController < ApplicationController
	def index
		@users = User.all
	end
end

# app/models/user.rb
class User < ApplicationRecord
end

你可能會想說,這不是很正常嗎?其他語言也都是這樣做的啊?是很正常沒錯,但真的很麻煩!

一個正常的 Controller,用到兩到三個 Model 都是很正常的,可能也會用到其他自定義的 Class,那這樣引用名單就會很長,人工維護也可能有時候會漏掉一些,如果可以省掉這些麻煩,豈不美哉?

讓我想到之前還在寫 Android 的時候,每當我使用了其他 class,Android Studio 都會跳出紅驚嘆號,自動問我要不要引用某檔案進來,當時我還覺得挺方便的,但接觸到 Rails 之後,覺得還是 Rails 這樣不需要引用更好一點 XD,不過對於 Rails 新手來說,這些不需要引用的特性,會造成一種「不確定感」,讓之前剛學 Rails 的我覺得非常痛苦,我會不知道我能不能「這樣」使用,我「這樣」的使用方式,有沒有符合 Rails 的慣例 等等的疑問。

在 Rails 的設計哲學當中,「慣例優於設定」的目的,就是要讓開發者免於各種繁雜的設定手續,所以 Rails 跟開發者們共同維護著一種慣例:「你只要照著 Rails 訂好的規則走,你就不需要做這些繁雜的設定囉!」

幕後功臣 - Rails 怎麼做到 Autoloading 的?

如果是 Rails 7 的話,是透過 zeitwerk 這個 gem,提供 autoloader,在 rails 的 source coderails/railties/lib/rails/autoloaders.rb,可以看到 rails 一次 intialize 兩個 autoloader,一個是 main、一個是 once

Untitled

# rails/railties/lib/rails/autoloaders.rb
module Rails
  class Autoloaders
		...

    attr_reader :main, :once

    def initialize
      require "zeitwerk"

      @main = Zeitwerk::Loader.new
      @main.tag = "rails.main"
      @main.inflector = Inflector

      @once = Zeitwerk::Loader.new
      @once.tag = "rails.once"
      @once.inflector = Inflector
    end

		...
  end
end

並且在 rails/railties/lib/rails/application/finisher.rb,有將相依路徑都加到 autoload 的名單中的痕跡

# rails/railties/lib/rails/application/finisher.rb
module Rails
  class Application
    module Finisher
      include Initializable

      initializer :setup_main_autoloader do
        autoloader = Rails.autoloaders.main
				
				...

        ActiveSupport::Dependencies.autoload_paths.uniq.each do |path|
          next unless File.directory?(path)

          autoloader.push_dir(path)
          autoloader.do_not_eager_load(path) unless ActiveSupport::Dependencies.eager_load?(path)
        end
				
				...

        autoloader.setup
      end

Rails 的 autoloading 機制

只要你符合以下 Rails 所定義的「慣例」,Rails 就會幫你把 ruby 檔案做 autoloading:

  1. app/ 底下所有資料夾底下的 rb 檔案(包括開發者另外新增的資料夾)

    1. 例外:javascriptassetsviews 三個資料夾不會被加進 autoloading
  2. 檔案名 和 Class 名必須是 Camel 關係:

    1. users_controller.rb → UsersController
    2. html_parser.rb → HtmlParser
    3. ssl_error.rb → SslError

    可自行替換規則

    # 第一種替換規則的方法
    # config/initializers/inflections.rb
    ActiveSupport::Inflector.inflections(:en) do |inflect|
      inflect.acronym "HTML"
      inflect.acronym "SSL"
    end
    # 優點是簡單好設定,缺點是這個設定是全域的
    
    # 第二種替換規則的方法
    # config/initializers/zeitwerk.rb
    Rails.autoloaders.each do |autoloader|
      autoloader.inflector.inflect(
        "html_parser" => "HTMLParser",
        "ssl_error"   => "SSLError"
      )
    end
    
  3. 如果不在規則內的,可以在 config/application.rbconfig/environments/你想加入規則的環境.rb 新增想要被 autoload 的路徑

    module YourApplication
      class Application < Rails::Application
        ...
    		config.autoload_paths << "#{Rails.root}/your_path"
      end
    end
    

不適用 Autoloading 的情況!

通常發生在 Rails 已經啟動(boot)的狀態,會有一些狀況:

第一種情況: initializer 只會被執行一次

# config/initializers/api_gateway_setup.rb
ApiGateway.endpoint = "https://example.com" # DO NOT DO THIS

因為 initializer 只會在 rails 啟動的時候執行,在整個啟動的期間只會執行一次,所以如果你在 initializer 中寫了上面的初始化動作,那當 ApiGateway 被 reload 之後,ApiGateway.endpoint 因為沒有再次執行 initializer,導致 ApiGateway.endpoint 會是 nil

如果真的要在 initializer 中,做初始化某個值的動作,那可以用以下方式

# config/initializers/api_gateway_setup.rb
Rails.application.config.to_prepare do
	ApiGateway.endpoint = "https://example.com" # CORRECT
end

Rails.application.config.to_prepare 包起來的部分,將會在每個 reload 之後也被執行

ps. 官方有警告說,to_prepare 中的程式 可能 會被執行兩次,所以裡面的程式必須是執行一次、兩次都無所謂的

第二種情況: Object 被 cached

例如 middleware 已指定 MyApp::Middleware::Foo 這個 module,但當 MyApp::Middleware::Foo reload 之後,在 middleware 內的,卻還是舊版的 MyApp::Middleware::Foo

config.middleware.use MyApp::Middleware::Foo

Rails console 中,沒有 file watcher

所以如果開了 rails console 之後,檔案有改動的話,需要手動輸入 reload!,來取得最新的 code

官方有解釋説,rails console 比較像是一個個獨立運作的 request,所以如果要在運作中的 console reload 的話,會造成前後不一致的狀況

Reloading = Unloading + Loading

Unloading 等於 Object.send(:remove_const, object_name),就是把這個 class 給移除

$ rails console
$ Object.send(:remove_const, 'User') # 把 User model 拿掉
=> User(id: integer, name: string, created_at: datetime, updated_at: datetime, ...)
$ User
NameError: uninitialized constant User
Did you mean?  Users

手動檢查是否有符合規則

$ bin/rails zeitwerk:check

Hold on, I am eager loading the application.
All is good!

故意弄出一個不符合規則的,我在 app/controllers 新增一個 abc_controller.rb,按照慣例應該要叫做 AbcController,但我故意把它叫做 ABCController

$ bin/rails zeitwerk:check

Hold on, I am eager loading the application.
expected file app/controllers/abc_controller.rb to define constant AbcController

讓 autoloader 輸出 Log

# config/application.rb
...
module YourApplicationName
  class Application < Rails::Application
		...
		Rails.autoloaders.logger = Logger.new("#{Rails.root}/log/autoloading.log")
  end
end

可以讓 autoloader 把 log 吐出來到 log/autoloading.log 這個檔案

D, [2022-08-29T12:22:00.540687 #4915] DEBUG -- : Zeitwerk@rails.main: autoload set for ActiveStorage::Filename, to be loaded from /Users/unclesam/.rvm/gems/ruby-3.0.0/gems/activestorage-6.1.6.1/app/models/active_storage/filename.rb
D, [2022-08-29T12:22:00.540701 #4915] DEBUG -- : Zeitwerk@rails.main: autoload set for ActiveStorage::Preview, to be loaded from /Users/unclesam/.rvm/gems/ruby-3.0.0/gems/activestorage-6.1.6.1/app/models/active_storage/preview.rb
D, [2022-08-29T12:22:00.540717 #4915] DEBUG -- : Zeitwerk@rails.main: autoload set for ActiveStorage::Record, to be loaded from /Users/unclesam/.rvm/gems/ruby-3.0.0/gems/activestorage-6.1.6.1/app/models/active_storage/record.rb
D, [2022-08-29T12:22:00.540732 #4915] DEBUG -- : Zeitwerk@rails.main: autoload set for ActiveStorage::Variant, to be loaded from /Users/unclesam/.rvm/gems/ruby-3.0.0/gems/activestorage-6.1.6.1/app/models/active_storage/variant.rb
D, [2022-08-29T12:22:00.540750 #4915] DEBUG -- : Zeitwerk@rails.main: autoload set for ActiveStorage::VariantRecord, to be loaded from /Users/unclesam/.rvm/gems/ruby-3.0.0/gems/activestorage-6.1.6.1/app/models/active_storage/variant_record.rb
D, [2022-08-29T12:22:00.540770 #4915] DEBUG -- : Zeitwerk@rails.main: autoload set for ActiveStorage::VariantWithRecord, to be loaded from /Users/unclesam/.rvm/gems/ruby-3.0.0/gems/activestorage-6.1.6.1/app/models/active_storage/variant_with_record.rb
D, [2022-08-29T12:22:00.540787 #4915] DEBUG -- : Zeitwerk@rails.main: autoload set for ActiveStorage::Variation, to be loaded from /Users/unclesam/.rvm/gems/ruby-3.0.0/gems/activestorage-6.1.6.1/app/models/active_storage/variation.rb
D, [2022-08-29T12:22:00.591097 #4915] DEBUG -- : Zeitwerk@rails.main: constant ApplicationRecord loaded from file /Users/unclesam/Projects/fullstack/online_course/app/models/application_record.rb
D, [2022-08-29T12:22:00.595789 #4915] DEBUG -- : Zeitwerk@rails.main: constant Oauthable loaded from file /Users/unclesam/Projects/fullstack/online_course/app/models/concerns/oauthable.rb
D, [2022-08-29T12:22:00.595938 #4915] DEBUG -- : Zeitwerk@rails.main: constant User loaded from file /Users/unclesam/Projects/fullstack/online_course/app/models/user.rb

與 Autoloading 相關的指令

Rails.autoloaders.main

Rails.autoloaders.once

ActiveSupport::Dependencies.autoload_paths

puts($LOAD_PATH)

Rails.application.config.after_initialize

Rails.application.config.to_prepare

總結

恩… 老實說這篇我覺得不知道自己在看什麼XD,心中默默 OS:「反正我就是開發的時候,覺得怪怪的時候就直接重開 rails server 就對了麻!」,


上一篇
第二天:在 RailsGuides 無意間掏到寶藏?!
下一篇
第四天:Ruby + ActiveSupport = Ruby 穿全身+9神裝!
系列文
Rails,我要進來囉30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言